跳到正文

Java Agent 与字节码增强 ​

一句话:Java Agent 在类加载或重定义的那一刻把探针织进字节码,让业务代码不改一行就能被观测;它换来的是零侵入,代价是类加载可见性、栈帧合法性和失败降级三条线必须同时守住。

适用 JDK 17 / JDK 21,ASM 9.x。Attach 的连接过程见 10-Attach机制与运行时注入,本篇从拿到 Instrumentation 之后讲起。

为什么需要 ​

APM、链路追踪、覆盖率、故障注入都要求「不改业务代码」。源码埋点要改代码、走发布;Spring AOP 只能拦住 bean 的外部调用;编译期插件要求改构建。Agent 是少数能在类进入 JVM 的那一刻统一改写字节码的位置。

代价是:你的代码跑在别人的类加载流程里。改错一条指令,业务类就 VerifyError;放错一个 jar,业务就 NoClassDefFoundError;异常没兜住,进程可能直接起不来。难点从来不是「插代码」,而是「插完还能正常跑」。

核心机制 ​

两个入口 ​

入口触发方式时机manifest 键
premain(String, Instrumentation)-javaagent:agent.jarmain 之前Premain-Class
agentmain(String, Instrumentation)Attach 后 loadAgentJVM 运行中Agent-Class

manifest 至少声明 Premain-Class(或 Agent-Class)和 Can-Retransform-Classes: true;后者是 addTransformer(t, true) 和 retransformClasses 的前提,Can-Redefine-Classes: true 对应 redefineClasses,少了对应键 API 直接抛 UnsupportedOperationException。

premain 在应用主线程启动前同步执行;agentmain 在 Attach 的加载线程上同步执行,长耗时初始化会挡住后续 attach。两者的异常都要自己 catch:premain 抛出可能导致启动失败,agentmain 抛出则让这次注入静默失效。

Instrumentation 的能力边界 ​

  • addTransformer(t) 只作用于之后加载的类;addTransformer(t, true) 额外允许它在重转换时再次被调用。
  • retransformClasses(Class...) 把类退回原始字节码,再依次跑所有可重转换的 transformer。能改方法体、常量池、属性;不能增删字段/方法、不能改签名、不能改继承。
  • redefineClasses 直接提供字节码、绕过 transformer 链,限制同上。它没有废弃,但通常用 retransform,因为后者复用转换链、可重复执行。
  • 两者都是 VM 操作,会进安全点;只对 isModifiableClass 为 true 的类有效,数组、基本类型、hidden class 不行。

一个容易混淆的点:首次加载时 transformer 返回什么合法类都行,加字段、加方法都可以,因为那是这个类第一次被定义;retransform/redefine 才受「结构不变」约束。不少运行期 attach 的方案因此不在字节码里加字段,而把探针数组放到外部映射。

ClassFileTransformer ​

transform(loader, className, classBeingRedefined, pd, bytes) 的约定:

  • className 是内部格式 com/example/Foo,不是点分名;初始加载时 classBeingRedefined 为 null,重转换时是被重定义的 Class。返回 null 表示不修改;要改就返回新数组,不得原地修改入参 buffer。
  • 多个 transformer 按注册顺序串联,前一个的输出是后一个的输入。
  • transformer 抛异常等价于返回 null:类照常加载,增强悄悄丢失。所以自己 catch 并记日志,别指望异常能拦住什么。

类加载器隔离 ​

被增强的业务类里会出现 INVOKESTATIC demo/Probe.hit 这样的直接引用,它由定义业务类的加载器解析。如果 Probe 只在 agent 自己的加载器里,业务类就找不到它,运行到探针时 NoClassDefFoundError。

因此通常拆两层:探针壳(无依赖,几十行)用 appendToBootstrapClassLoaderSearch 放进 bootstrap,所有加载器可见;agent 核心(ASM、配置、上报)用自定义 URLClassLoader 以 platform loader 为父、child-first 加载,与业务依赖隔离。premain 里先放探针壳,再用反射调核心的 start,这样 agent 入口类不直接依赖核心。把整个 agent(含 ASM)塞进 bootstrap 是常见的过度操作:bootstrap 加载的类不会被卸载,ASM 对所有业务可见,业务自带同名库时直接打架。探针壳只做转发,通过静态 holder 或反射把调用交给核心。Spring Boot fat jar 由 LaunchedURLClassLoader 加载、父加载器是 system loader,appendToSystemClassLoaderSearch 在纯 -cp 场景够用,但不是所有容器都成立;要覆盖所有加载器,用 bootstrap。

ASM 流水线 ​

ClassReader 把 class 文件解析成事件流,ClassVisitor 边收边转发,MethodVisitor 在方法事件里插指令,ClassWriter 再把事件写成字节。全程不建 AST,所以快;代价是栈、局部变量和帧都要你自己保证一致。

  • new ClassWriter(cr, flags) 传入 reader 会复用常量池,未访问的方法体原样拷贝;不传则全量重写,慢且容易丢属性。
  • COMPUTE_MAXS 只重算 max_stack/max_locals;COMPUTE_FRAMES 还重算栈映射帧,更贵,且默认的 getCommonSuperClass 会 Class.forName 加载类——在类加载锁里加载别的类,可能死锁或拿到错误版本。需要时重写它,用目标类的加载器解析,失败时退到 java/lang/Object。
  • Java 7(class 版本 51)起,每个跳转目标都要有栈映射帧(stack map frame)。只在方法入口插一条不产生新跳转的调用,COMPUTE_MAXS 就够;一旦新增分支目标,必须 COMPUTE_FRAMES 或手工 visitFrame。
  • 新增局部变量槽要同步 max_locals;LocalVariableTable 是调试信息,缺失不会 VerifyError,但排障和报告会丢名字。ASM 版本要跟得上 class 文件版本,报 Unsupported class file major version 就是 ASM 落后于 JDK,升级 ASM,不要绕过。

探针设计 ​

探针要满足三条:不改语义、不分配对象、不吞异常。

  • 只写常量到数组,不动栈上的返回值:GETSTATIC Probe.HIT : [Z + BIPUSH id + ICONST_1 + BASTORE,几条指令、无锁、无分配;IRETURN 前栈顶是返回值,探针只能压入并消费自己的常量,净效果为 0。反射、字符串拼接、Map 查找放进热点方法都是灾难。
  • 异常路径也要记:只做行覆盖时,入口和行号标签各写一次即可;要做方法耗时,得用 try/finally 或 catch-all handler,否则 ATHROW 路径漏记。
  • 跳过不该碰的方法:abstract/native 没有 Code 属性;bridge 和 synthetic 是编译器生成的转发;lambda 体是 lambda$... 的 synthetic 方法,增强它通常只是重复计数。
  • 构造器与幂等:super()/this() 之前 this 未初始化,不能读写实例成员,静态数组写入本身合法但插在 super() 之后更稳;retransform 从原始字节重新开始,天然不叠加,但多个 transformer 串联或同一 agent 注册两次仍会插两遍,可用常量标记跳过。

性能与稳定性 ​

增强发生在类加载线程上,通常在类加载锁内:单个类 transform 慢,会拖住同一加载器上的其他类;COMPUTE_FRAMES 在锁内加载父类,是最典型的死锁来源;类被反复 retransform 还会带来元空间抖动。运行时开销来自每次方法调用的几次数组写,热点方法上还要关注同一 cache line 的写争用。失败策略只有一条:降级为不增强。transform 里 catch Throwable 返回 null,premain/agentmain 整体 try-catch,任何异常都不冒泡到 JVM。业务少一份监控可以接受,起不来不可以。

最小上手 ​

JDK 17 + ASM 9.7。Agent、Probe、App 都编进同一个目录,App 由应用加载器加载,因此能直接看到 Probe;生产里探针壳不可见时再放 bootstrap。

java
// Agent.java:省略 import 与错误处理
public class Agent {
    public static void premain(String args, Instrumentation inst) {
        inst.addTransformer((l, name, redef, pd, bytes) -> {
            if (!"demo/App".equals(name)) return null;
            ClassReader cr = new ClassReader(bytes);
            ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
            cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
                public MethodVisitor visitMethod(int acc, String n, String d, String s, String[] e) {
                    MethodVisitor mv = super.visitMethod(acc, n, d, s, e);
                    if ((acc & Opcodes.ACC_ABSTRACT) != 0 || n.equals("<init>")) return mv;
                    return new MethodVisitor(Opcodes.ASM9, mv) {
                        public void visitCode() {
                            mv.visitLdcInsn(1);
                            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "demo/Probe", "hit", "(I)V", false);
                        }
                    };
                }
            }, 0);
            return cw.toByteArray();
        });
    }
}
bash
# Probe.hit(int) 在示例里打印,生产探针只写数组
javac -cp asm-9.7.jar -d out Agent.java demo/Probe.java demo/App.java
jar --create --file agent.jar --manifest MANIFEST.MF -C out .
java -javaagent:agent.jar -cp out demo.App
# probe 1
# hello

Probe(demo 包)只有一行 public static void hit(int id) { System.out.println("probe " + id); }。看到 probe 1 说明方法入口已被改写;javap -c -p -cp out demo.App 能看到 invokestatic demo/Probe.hit。

生产动作 ​

  • 按需开启、能力最小化:-javaagent 只加在需要采集的实例上,增强范围用配置中心开关控制;不需要重转换就不写 Can-Retransform-Classes,也不开 Can-Redefine-Classes。
  • 过滤先便宜后昂贵:先 startsWith 前缀判断,再跑预编译的正则;不要遍历 getAllLoadedClasses() 全量 retransform。
  • 元空间兜底:设 -XX:MaxMetaspaceSize;用 jcmd <pid> VM.metaspace 和 jcmd <pid> VM.classloader_stats 看类数量。
  • 可回滚:removeTransformer 后对目标类 retransformClasses 能退回原始字节码;但 agent 的类、线程、连接不会消失,彻底回滚要重启。
  • JDK 21 动态加载:JEP 451 对运行期 attach agent 告警,需要时显式加 -XX:+EnableDynamicAgentLoading,并当作受控变更。
现象证据判断
NoClassDefFoundError: demo/Probe-Xlog:class+load=info 看 Probe 由谁加载探针壳不在业务加载器可见范围,改放 bootstrap
VerifyError: Expecting a stackmap frame异常中的类名与方法新增了跳转却没重算帧,改用 COMPUTE_FRAMES 或去掉跳转
启动变慢、类加载卡住jcmd <pid> Thread.print 是否停在 ClassLoader.loadClasstransform 在锁内做了重活或加载了别的类
元空间持续上涨jcmd <pid> VM.metaspace反复 retransform 或增强类过多

边界 ​

  • native 方法改不了:没有 Code 属性,transformer 无从下手;JDK 核心类即使 isModifiableClass 为 true 也不建议增强,它们的假设写在 JVM 和运行库里。
  • 已加载类不会自动生效:不调 retransformClasses,新注册的 transformer 只影响之后的加载;运行期也改不了字段、方法和继承结构。
  • JPMS 与安全管理器:JDK 9+ agent 在无名模块,读不到其他模块的非导出包,需要 --add-opens/--add-exports 或 manifest 的 Add-Opens;SecurityManager 在 JDK 17 已废弃待移除,不能当隔离手段。
  • agent 不是插件框架,也不该承载业务逻辑:能摘 transformer、能回滚字节码,但 agent 的类、线程和连接不会卸载;诊断可以,核心逻辑必须回到代码和发布流程,否则一次加载失败就是一次线上事故。
  • 测试环境的无侵入 Mock:通过 retransform 在被 Mock 方法入口插入提前返回(或抛异常)的字节码,业务代码零改动,适合联调时替换外部依赖。它只适合测试/联调环境,生产必须禁用或强鉴权后按类/方法白名单临时开启,并且开关要能单独回滚。
  • GraalVM native image 不适用:没有传统类加载和 Instrumentation。

讲清楚 ​

Agent 是在类加载那一刻改字节码的钩子:premain 管启动前,agentmain 管运行中。拿到 Instrumentation 后能注册 transformer、能重转换已加载的类,但重转换只能改方法体,不能加字段方法或改继承;首次加载不受这个限制。它适合诊断和观测,不适合承载业务逻辑。

工程难点在类加载器:探针壳要放进 bootstrap 才能被业务类看见,agent 依赖要用独立加载器隔离。ASM 把字节码读成事件流再写回,插了跳转就必须重算栈帧。最后一条原则:增强失败一律降级为不增强,绝不让业务起不来。

把一门技术讲透到既懂也会用